aboutsummaryrefslogtreecommitdiff
path: root/src/routes/projects/[projectId]/routine/[routineId]
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/projects/[projectId]/routine/[routineId]')
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts80
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte440
2 files changed, 520 insertions, 0 deletions
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
new file mode 100644
index 0000000..83bac3c
--- /dev/null
+++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
@@ -0,0 +1,80 @@
+import { error, fail } from '@sveltejs/kit';
+import { getById } from '$lib/server/services/projects';
+import {
+ edit,
+ complete,
+ loadCurrent,
+ loadCompletions,
+} from '$lib/server/services/routines';
+import { numericId, parseFormString } from '$lib/server/services/validation';
+import {
+ parseIntervalToDays,
+ computeRoutineState,
+} from '$lib/shared/routine-metrics';
+import type { Actions, PageServerLoad } from './$types';
+
+function parseFrequencyDays(value: FormDataEntryValue | null): number {
+ const days = parseIntervalToDays(value?.toString() ?? '');
+ if (days === null) {
+ throw new Error('Invalid interval');
+ }
+ return days;
+}
+
+export const load: PageServerLoad = async ({ params }) => {
+ const projectId = numericId(params.projectId);
+ const routineId = numericId(params.routineId);
+
+ const project = await getById(projectId);
+ if (!project || project.archived) {
+ throw error(404, 'Projet introuvable');
+ }
+
+ const routine = await loadCurrent(routineId);
+ if (!routine || routine.archived || routine.projectId !== projectId) {
+ throw error(404, 'Routine introuvable');
+ }
+
+ const completions = await loadCompletions(routineId);
+ return {
+ project: { id: project.id, name: project.name },
+ routine,
+ completions,
+ state: computeRoutineState(routine, completions, Date.now()),
+ };
+};
+
+export const actions: Actions = {
+ edit: async ({ request, params }) => {
+ const data = await request.formData();
+ const name = parseFormString(data.get('name'));
+ if (!name) {
+ return fail(400, { error: 'Le nom est requis' });
+ }
+ const description = data.get('description')?.toString() ?? '';
+ try {
+ await edit(numericId(params.routineId), {
+ name,
+ frequencyDays: parseFrequencyDays(data.get('frequencyDays')),
+ description,
+ });
+ } catch {
+ return fail(400, { error: 'Fréquence invalide' });
+ }
+ return { success: true, type: 'edit' as const };
+ },
+
+ complete: async ({ request, params }) => {
+ const data = await request.formData();
+ try {
+ await complete(
+ numericId(params.routineId),
+ parseFormString(data.get('note')),
+ new Date().toISOString(),
+ );
+ } catch {
+ return fail(400, { error: 'Routine introuvable' });
+ }
+ return { success: true };
+ },
+};
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
new file mode 100644
index 0000000..ab0774c
--- /dev/null
+++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
@@ -0,0 +1,440 @@
+<script lang="ts">
+ import { enhance } from '$app/forms';
+ import {
+ formatFrequency,
+ parseIntervalToDays,
+ STATUS_LABELS,
+ computeHealthSeries,
+ type RoutineState,
+ } from '$lib/shared/routine-metrics';
+
+ let { data, form } = $props();
+
+ let name = $state('');
+ let frequency = $state('');
+ let description = $state('');
+ let note = $state('');
+ let saved = $state(false);
+
+ $effect(() => {
+ name = data.routine.name;
+ frequency = `${data.routine.frequencyDays}d`;
+ description = data.routine.description;
+ });
+
+ $effect(() => {
+ if (form?.success && form?.type === 'edit') {
+ saved = true;
+ const t = setTimeout(() => (saved = false), 2000);
+ return () => clearTimeout(t);
+ }
+ });
+
+ const frequencyDays = $derived(parseIntervalToDays(frequency));
+
+ const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
+ day: 'numeric',
+ month: 'short',
+ });
+ const dateTimeFormatter = new Intl.DateTimeFormat('fr-FR', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+
+ const series = $derived(
+ computeHealthSeries(data.routine, data.completions, Date.now()),
+ );
+
+ const PAD = 10;
+ const W = 800;
+ const H = 140;
+ const geometry = $derived.by(() => {
+ if (series.length < 2) return null;
+ const minT = series[0].at;
+ const maxT = series[series.length - 1].at;
+ const span = Math.max(1, maxT - minT);
+ const x = (t: number) => PAD + ((t - minT) / span) * (W - 2 * PAD);
+ const y = (h: number) => H - PAD - (h / 100) * (H - 2 * PAD);
+ const points = series
+ .map((p) => `${x(p.at).toFixed(1)},${y(p.health).toFixed(1)}`)
+ .join(' ');
+ const grid = [0, 25, 50, 75, 100].map((h) => ({ y: y(h), label: h }));
+ const markers = data.completions
+ .map((c) => {
+ const t = Date.parse(c.doneAt);
+ return t >= minT && t <= maxT ? { x: x(t), y: y(100) } : null;
+ })
+ .filter((m): m is { x: number; y: number } => m !== null);
+ return { points, grid, markers };
+ });
+
+ function scoreClass(score: number): string {
+ if (score >= 80) return 'score score--good';
+ if (score >= 50) return 'score score--mid';
+ return 'score score--bad';
+ }
+
+ function statusClass(status: RoutineState['status']): string {
+ if (status === 'ok') return 'status status--ok';
+ if (status === 'due') return 'status status--due';
+ if (status === 'late') return 'status status--late';
+ return 'status status--very-late';
+ }
+</script>
+
+<main>
+ <a class="back-link" href="/projects/{data.project.id}"
+ >← {data.project.name}</a
+ >
+
+ <header class="page-header">
+ <h1>{data.routine.name}</h1>
+ <span class={scoreClass(data.state.health)}>{data.state.health}</span>
+ </header>
+
+ <form
+ class="form-card"
+ method="POST"
+ action="?/edit"
+ use:enhance={() => {
+ return async ({ result, update }) => {
+ await update({ reset: false });
+ };
+ }}
+ >
+ {#if form?.error}
+ <p class="error">{form.error}</p>
+ {/if}
+ <label>
+ <span class="form-label">Nom</span>
+ <input
+ class="form-input"
+ type="text"
+ name="name"
+ bind:value={name}
+ autocomplete="off"
+ />
+ </label>
+ <label>
+ <span class="form-label">Intervalle</span>
+ <input
+ class="form-input frequency"
+ class:input-invalid={frequency !== '' && frequencyDays === null}
+ type="text"
+ name="frequencyDays"
+ bind:value={frequency}
+ placeholder="2w"
+ autocomplete="off"
+ aria-label="Intervalle (ex: 1d, 2w, 3m)"
+ />
+ </label>
+ <label>
+ <span class="form-label">Description</span>
+ <textarea
+ class="form-input desc"
+ name="description"
+ bind:value={description}
+ rows={3}
+ placeholder="Pourquoi, comment, quand..."
+ autocomplete="off"
+ ></textarea>
+ </label>
+ {#if frequency !== '' && frequencyDays === null}
+ <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p>
+ {/if}
+ <button
+ class="btn-primary"
+ type="submit"
+ disabled={!name.trim() || frequencyDays === null}
+ >
+ Enregistrer
+ </button>
+ <p class="save-status" class:visible={saved}>✓ Enregistré</p>
+ </form>
+
+ <h2 class="section-title">Santé</h2>
+ <div class="form-card chart-card">
+ {#if geometry}
+ <svg
+ class="health-chart"
+ viewBox="0 0 {W} {H}"
+ preserveAspectRatio="none"
+ role="img"
+ aria-label="Évolution de la santé de la routine dans le temps"
+ >
+ {#each geometry.grid as line (line.label)}
+ <line
+ x1={PAD}
+ y1={line.y}
+ x2={W - PAD}
+ y2={line.y}
+ class="chart-grid"
+ />
+ {/each}
+ <polyline points={geometry.points} class="chart-line" fill="none" />
+ {#each geometry.markers as m (m.x)}
+ <circle cx={m.x} cy={m.y} r="4" class="chart-marker" />
+ {/each}
+ </svg>
+ <div class="chart-footer">
+ <span class={statusClass(data.state.status)}>
+ {STATUS_LABELS[data.state.status]}
+ {#if data.state.overdueDays > 0}({data.state.overdueDays} j){/if}
+ </span>
+ <span class="chart-meta">
+ {formatFrequency(data.routine.frequencyDays)}
+ {#if data.state.lastDoneAt}
+ · fait le {dateFormatter.format(new Date(data.state.lastDoneAt))}
+ {:else}
+ · jamais fait
+ {/if}
+ </span>
+ </div>
+ {:else}
+ <p class="empty">Pas encore assez d'historique pour tracer la courbe.</p>
+ {/if}
+ </div>
+
+ <h2 class="section-title">Historique</h2>
+ <form
+ class="form-card form-card--compact add-completion"
+ method="POST"
+ action="?/complete"
+ use:enhance={() => {
+ return async ({ update }) => {
+ note = '';
+ await update({ reset: false });
+ };
+ }}
+ >
+ <input
+ class="form-input"
+ type="text"
+ name="note"
+ bind:value={note}
+ placeholder="Note (optionnel)"
+ autocomplete="off"
+ />
+ <button class="btn-done" type="submit">Fait</button>
+ </form>
+
+ <ul class="item-list">
+ {#each data.completions as c (c.id)}
+ <li class="item-row item-row--card completion-row">
+ <span class="completion-date">
+ {dateTimeFormatter.format(new Date(c.doneAt))}
+ </span>
+ {#if c.note}
+ <span class="completion-note">{c.note}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+
+ {#if data.completions.length === 0}
+ <p class="empty">Aucune réalisation pour l'instant.</p>
+ {/if}
+</main>
+
+<style>
+ .back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ margin-top: 1rem;
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: color 0.2s;
+ }
+
+ .back-link:hover {
+ color: var(--accent);
+ }
+
+ .page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 0;
+ }
+
+ .page-header h1 {
+ overflow-wrap: anywhere;
+ }
+
+ .frequency {
+ max-width: 8rem;
+ }
+
+ .input-invalid {
+ border-color: var(--danger);
+ }
+
+ .desc {
+ width: 100%;
+ resize: vertical;
+ font-family: inherit;
+ }
+
+ .save-status {
+ font-size: 0.875rem;
+ color: var(--success);
+ font-weight: 500;
+ visibility: hidden;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ min-height: 1.25rem;
+ }
+
+ .save-status.visible {
+ visibility: visible;
+ opacity: 1;
+ }
+
+ .section-title {
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--accent);
+ margin: 1.5rem 0 0.5rem;
+ padding-left: 0.5rem;
+ border-left: 3px solid var(--accent);
+ }
+
+ .chart-card {
+ align-items: stretch;
+ }
+
+ .health-chart {
+ width: 100%;
+ height: 9rem;
+ }
+
+ .chart-grid {
+ stroke: var(--border);
+ stroke-width: 1;
+ stroke-dasharray: 2 4;
+ }
+
+ .chart-line {
+ stroke: var(--accent);
+ stroke-width: 2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+ }
+
+ .chart-marker {
+ fill: var(--success);
+ stroke: var(--white);
+ stroke-width: 1.5;
+ }
+
+ .chart-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+
+ .chart-meta {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ }
+
+ .status {
+ flex-shrink: 0;
+ padding: 0.25rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ white-space: nowrap;
+ }
+
+ .status--ok {
+ background: var(--success);
+ color: var(--white);
+ }
+
+ .status--due {
+ background: var(--shadow-accent);
+ color: var(--accent);
+ }
+
+ .status--late {
+ background: var(--accent);
+ color: var(--white);
+ }
+
+ .status--very-late {
+ background: var(--danger);
+ color: var(--white);
+ }
+
+ .score {
+ flex-shrink: 0;
+ min-width: 2.75rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 9999px;
+ text-align: center;
+ font-size: 0.875rem;
+ font-weight: 700;
+ color: var(--white);
+ }
+
+ .score--good {
+ background: var(--success);
+ }
+
+ .score--mid {
+ background: var(--accent);
+ }
+
+ .score--bad {
+ background: var(--danger);
+ }
+
+ .add-completion {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ .add-completion .form-input {
+ flex: 1;
+ }
+
+ .btn-done {
+ flex-shrink: 0;
+ padding: 0.75rem 1.25rem;
+ border: none;
+ border-radius: 0.75rem;
+ background: var(--success);
+ color: var(--white);
+ font-size: 0.9375rem;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ }
+
+ .btn-done:hover {
+ filter: brightness(1.05);
+ transform: translateY(-1px);
+ }
+
+ .completion-row {
+ flex-wrap: wrap;
+ gap: 0.25rem 0.75rem;
+ }
+
+ .completion-date {
+ font-weight: 500;
+ }
+
+ .completion-note {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ }
+</style>